package com.nulldev.util.internal.backport.httpclient_rw;

import static java.net.Authenticator.RequestorType.PROXY;
import static java.net.Authenticator.RequestorType.SERVER;
import static java.nio.charset.StandardCharsets.ISO_8859_1;

import java.io.IOException;
import java.net.InetSocketAddress;
import java.net.MalformedURLException;
import java.net.PasswordAuthentication;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.URL;
import java.util.Base64;
import java.util.LinkedList;
import java.util.WeakHashMap;

import com.nulldev.util.internal.backport.concurrency9.Objects;
import com.nulldev.util.internal.backport.httpclient_rw.backports.HeaderParser;
import com.nulldev.util.internal.backport.httpclient_rw.impl.common.Log;
import com.nulldev.util.internal.backport.httpclient_rw.impl.common.Utils;

/**
 * Implementation of Http Basic authentication.
 */
class AuthenticationFilter implements HeaderFilter {
	volatile MultiExchange<?> exchange;
	private static final Base64.Encoder encoder = Base64.getEncoder();

	static final int DEFAULT_RETRY_LIMIT = 3;

	static final int retry_limit = Utils.getIntegerNetProperty("jdk.httpclient.auth.retrylimit", DEFAULT_RETRY_LIMIT);

	static final int UNAUTHORIZED = 401;
	static final int PROXY_UNAUTHORIZED = 407;

	private static final String BASIC_DUMMY = "Basic " + Base64.getEncoder().encodeToString("o:o".getBytes(ISO_8859_1));

	// A public no-arg constructor is required by FilterFactory
	public AuthenticationFilter() {
	}

	private PasswordAuthentication getCredentials(String header, boolean proxy, HttpRequestImpl req) throws IOException {
		HttpClientImpl client = exchange.client();
		java.net.Authenticator auth = client.authenticator().orElseThrow(() -> new IOException("No authenticator set"));
		URI uri = req.uri();
		HeaderParser parser = new HeaderParser(header);
		String authscheme = parser.findKey(0);

		String realm = parser.findValue("realm");
		java.net.Authenticator.RequestorType rtype = proxy ? PROXY : SERVER;
		URL url = toURL(uri, req.method(), proxy);
		String host;
		int port;
		String protocol;
		InetSocketAddress proxyAddress;
		if (proxy && (proxyAddress = req.proxy()) != null) {
			// request sent to server through proxy
			proxyAddress = req.proxy();
			host = proxyAddress.getHostString();
			port = proxyAddress.getPort();
			protocol = "http"; // we don't support https connection to proxy
		} else {
			// direct connection to server or proxy
			host = uri.getHost();
			port = uri.getPort();
			protocol = uri.getScheme();
		}

		// needs to be instance method in Authenticator
		return AuthenticatorHack.requestPasswordAuthenticationInstance(auth, host, null, port, protocol, realm, authscheme, url, rtype);
	}

	private URL toURL(URI uri, String method, boolean proxy) throws MalformedURLException {
		if (proxy && "CONNECT".equalsIgnoreCase(method) && "socket".equalsIgnoreCase(uri.getScheme())) {
			return null; // proxy tunneling
		}
		return uri.toURL();
	}

	private URI getProxyURI(HttpRequestImpl r) {
		InetSocketAddress proxy = r.proxy();
		if (proxy == null) {
			return null;
		}

		// our own private scheme for proxy URLs
		// eg. proxy.http://host:port/
		String scheme = "proxy." + r.uri().getScheme();
		try {
			return new URI(scheme, null, proxy.getHostString(), proxy.getPort(), "/", null, null);
		} catch (URISyntaxException e) {
			throw new InternalError(e);
		}
	}

	@Override
	public void request(HttpRequestImpl r, MultiExchange<?> e) throws IOException {
		// use preemptive authentication if an entry exists.
		Cache cache = getCache(e);
		this.exchange = e;

		// Proxy
		if (exchange.proxyauth == null) {
			URI proxyURI = getProxyURI(r);
			if (proxyURI != null) {
				CacheEntry ca = cache.get(proxyURI, true);
				if (ca != null) {
					exchange.proxyauth = new AuthInfo(true, ca.scheme, null, ca);
					addBasicCredentials(r, true, ca.value);
				}
			}
		}

		// Server
		if (exchange.serverauth == null) {
			CacheEntry ca = cache.get(r.uri(), false);
			if (ca != null) {
				exchange.serverauth = new AuthInfo(true, ca.scheme, null, ca);
				addBasicCredentials(r, false, ca.value);
			}
		}
	}

	// TODO: refactor into per auth scheme class
	private static void addBasicCredentials(HttpRequestImpl r, boolean proxy, PasswordAuthentication pw) {
		String hdrname = proxy ? "Proxy-Authorization" : "Authorization";
		StringBuilder sb = new StringBuilder(128);
		sb.append(pw.getUserName()).append(':').append(pw.getPassword());
		String s = encoder.encodeToString(sb.toString().getBytes(ISO_8859_1));
		String value = "Basic " + s;
		if (proxy) {
			if (r.isConnect()) {
				if (!Utils.PROXY_TUNNEL_FILTER.test(hdrname, value)) {
					Log.logError("{0} disabled", hdrname);
					return;
				}
			} else if (r.proxy() != null) {
				if (!Utils.PROXY_FILTER.test(hdrname, value)) {
					Log.logError("{0} disabled", hdrname);
					return;
				}
			}
		}
		r.setSystemHeader(hdrname, value);
	}

	// Information attached to a HttpRequestImpl relating to authentication
	static class AuthInfo {
		final boolean fromcache;
		final String scheme;
		int retries;
		PasswordAuthentication credentials; // used in request
		CacheEntry cacheEntry; // if used

		AuthInfo(boolean fromcache, String scheme, PasswordAuthentication credentials) {
			this.fromcache = fromcache;
			this.scheme = scheme;
			this.credentials = credentials;
			this.retries = 1;
		}

		AuthInfo(boolean fromcache, String scheme, PasswordAuthentication credentials, CacheEntry ca) {
			this(fromcache, scheme, credentials);
			assert credentials == null || (ca != null && ca.value == null);
			cacheEntry = ca;
		}

		AuthInfo retryWithCredentials(PasswordAuthentication pw) {
			// If the info was already in the cache we need to create a new
			// instance with fromCache==false so that it's put back in the
			// cache if authentication succeeds
			AuthInfo res = fromcache ? new AuthInfo(false, scheme, pw) : this;
			res.credentials = Objects.requireNonNull(pw);
			res.retries = retries;
			return res;
		}

	}

	@Override
	public HttpRequestImpl response(Response r) throws IOException {
		Cache cache = getCache(exchange);
		int status = r.statusCode();
		HttpHeaders hdrs = r.headers();
		HttpRequestImpl req = r.request();

		if (status != UNAUTHORIZED && status != PROXY_UNAUTHORIZED) {
			// check if any authentication succeeded for first time
			if (exchange.serverauth != null && !exchange.serverauth.fromcache) {
				AuthInfo au = exchange.serverauth;
				cache.store(au.scheme, req.uri(), false, au.credentials);
			}
			if (exchange.proxyauth != null && !exchange.proxyauth.fromcache) {
				AuthInfo au = exchange.proxyauth;
				URI proxyURI = getProxyURI(req);
				if (proxyURI != null) {
					cache.store(au.scheme, proxyURI, true, au.credentials);
				}
			}
			return null;
		}

		boolean proxy = status == PROXY_UNAUTHORIZED;
		String authname = proxy ? "Proxy-Authenticate" : "WWW-Authenticate";
		String authval = hdrs.firstValue(authname).orElse(null);
		if (authval == null) {
			if (exchange.client().authenticator().isPresent()) {
				throw new IOException(authname + " header missing for response code " + status);
			} else {
				// No authenticator? let the caller deal with this.
				return null;
			}
		}

		HeaderParser parser = new HeaderParser(authval);
		String scheme = parser.findKey(0);

		// TODO: Need to generalise from Basic only. Delegate to a provider class etc.

		if (!scheme.equalsIgnoreCase("Basic")) {
			return null; // error gets returned to app
		}

		if (proxy) {
			if (r.isConnectResponse) {
				if (!Utils.PROXY_TUNNEL_FILTER.test("Proxy-Authorization", BASIC_DUMMY)) {
					Log.logError("{0} disabled", "Proxy-Authorization");
					return null;
				}
			} else if (req.proxy() != null) {
				if (!Utils.PROXY_FILTER.test("Proxy-Authorization", BASIC_DUMMY)) {
					Log.logError("{0} disabled", "Proxy-Authorization");
					return null;
				}
			}
		}

		AuthInfo au = proxy ? exchange.proxyauth : exchange.serverauth;
		if (au == null) {
			// if no authenticator, let the user deal with 407/401
			if (!exchange.client().authenticator().isPresent())
				return null;

			PasswordAuthentication pw = getCredentials(authval, proxy, req);
			if (pw == null) {
				throw new IOException("No credentials provided");
			}
			// No authentication in request. Get credentials from user
			au = new AuthInfo(false, "Basic", pw);
			if (proxy) {
				exchange.proxyauth = au;
			} else {
				exchange.serverauth = au;
			}
			req = HttpRequestImpl.newInstanceForAuthentication(req);
			addBasicCredentials(req, proxy, pw);
			return req;
		} else if (au.retries > retry_limit) {
			throw new IOException("too many authentication attempts. Limit: " + Integer.toString(retry_limit));
		} else {
			// we sent credentials, but they were rejected
			if (au.fromcache) {
				cache.remove(au.cacheEntry);
			}

			// if no authenticator, let the user deal with 407/401
			if (!exchange.client().authenticator().isPresent())
				return null;

			// try again
			PasswordAuthentication pw = getCredentials(authval, proxy, req);
			if (pw == null) {
				throw new IOException("No credentials provided");
			}
			au = au.retryWithCredentials(pw);
			if (proxy) {
				exchange.proxyauth = au;
			} else {
				exchange.serverauth = au;
			}
			req = HttpRequestImpl.newInstanceForAuthentication(req);
			addBasicCredentials(req, proxy, au.credentials);
			au.retries++;
			return req;
		}
	}

	// Use a WeakHashMap to make it possible for the HttpClient to
	// be garbage collected when no longer referenced.
	static final WeakHashMap<HttpClientImpl, Cache> caches = new WeakHashMap<>();

	static synchronized Cache getCache(MultiExchange<?> exchange) {
		HttpClientImpl client = exchange.client();
		Cache c = caches.get(client);
		if (c == null) {
			c = new Cache();
			caches.put(client, c);
		}
		return c;
	}

	// Note: Make sure that Cache and CacheEntry do not keep any strong
	// reference to the HttpClient: it would prevent the client being
	// GC'ed when no longer referenced.
	static final class Cache {
		final LinkedList<CacheEntry> entries = new LinkedList<>();

		Cache() {
		}

		synchronized CacheEntry get(URI uri, boolean proxy) {
			for (CacheEntry entry : entries) {
				if (entry.equalsKey(uri, proxy)) {
					return entry;
				}
			}
			return null;
		}

		synchronized void remove(String authscheme, URI domain, boolean proxy) {
			for (CacheEntry entry : entries) {
				if (entry.equalsKey(domain, proxy)) {
					entries.remove(entry);
				}
			}
		}

		synchronized void remove(CacheEntry entry) {
			entries.remove(entry);
		}

		synchronized void store(String authscheme, URI domain, boolean proxy, PasswordAuthentication value) {
			remove(authscheme, domain, proxy);
			entries.add(new CacheEntry(authscheme, domain, proxy, value));
		}
	}

	static URI normalize(URI uri, boolean isPrimaryKey) {
		String path = uri.getPath();
		if (path == null || path.isEmpty()) {
			// make sure the URI has a path, ignore query and fragment
			try {
				return new URI(uri.getScheme(), uri.getAuthority(), "/", null, null);
			} catch (URISyntaxException e) {
				throw new InternalError(e);
			}
		} else if (isPrimaryKey || !"/".equals(path)) {
			// remove extraneous components and normalize path
			return uri.resolve(".");
		} else {
			// path == "/" and the URI is not used to store
			// the primary key in the cache: nothing to do.
			return uri;
		}
	}

	static final class CacheEntry {
		final String root;
		final String scheme;
		final boolean proxy;
		final PasswordAuthentication value;

		CacheEntry(String authscheme, URI uri, boolean proxy, PasswordAuthentication value) {
			this.scheme = authscheme;
			this.root = normalize(uri, true).toString(); // remove extraneous components
			this.proxy = proxy;
			this.value = value;
		}

		public PasswordAuthentication value() {
			return value;
		}

		public boolean equalsKey(URI uri, boolean proxy) {
			if (this.proxy != proxy) {
				return false;
			}
			String other = String.valueOf(normalize(uri, false));
			return other.startsWith(root);
		}
	}
}
